--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 8fab4278ff1871a4c6a26224d3e9f1a3105672d4
Parents : 10d4909
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T22:37:59-05:00
feat(plugin): fix plugin UI handling with new PluginSlotNode component and improved label mapping
Changes
13 files changed, 232 insertions(+), 78 deletions(-)
Diff
diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js b/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
index 657e625e..59bbdfc0 100644
--- a/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
+++ b/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
@@ -67,5 +67,7 @@ export async function activate(api) {
await refresh();
});
+ api.onRefresh(refresh);
+
await refresh();
}
diff --git a/meshchatx/src/frontend/components/plugins/PluginPage.vue b/meshchatx/src/frontend/components/plugins/PluginPage.vue
index 8134d02c..fdbcf537 100644
--- a/meshchatx/src/frontend/components/plugins/PluginPage.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginPage.vue
@@ -32,12 +32,14 @@ export default {
};
},
mounted() {
+ this.descriptor = pluginHost.getLastDescriptor(this.pluginId);
this.uiListener = (event) => {
if (event.detail?.pluginId === this.pluginId) {
this.descriptor = event.detail.descriptor;
}
};
window.addEventListener("meshchatx-plugin-ui", this.uiListener);
+ pluginHost.requestUiRefresh(this.pluginId);
},
beforeUnmount() {
window.removeEventListener("meshchatx-plugin-ui", this.uiListener);
diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
new file mode 100644
index 00000000..7757d3c4
--- /dev/null
+++ b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
@@ -0,0 +1,82 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <p
+ v-if="node.type === 'text'"
+ :class="
+ node.variant === 'title'
+ ? 'text-lg font-semibold text-gray-900 dark:text-gray-100'
+ : 'text-sm text-gray-700 dark:text-gray-300'
+ "
+ >
+ {{ node.value }}
+ </p>
+
+ <div v-else-if="node.type === 'input'" class="space-y-1">
+ <label v-if="node.label" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+ {{ node.label }}
+ </label>
+ <input
+ class="w-full rounded-md border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm"
+ type="text"
+ :placeholder="node.placeholder || ''"
+ :value="node.value || ''"
+ @input="$emit('input', { id: node.id, value: $event.target.value })"
+ />
+ </div>
+
+ <button
+ v-else-if="node.type === 'button'"
+ type="button"
+ class="px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700"
+ @click="$emit('action', node.id)"
+ >
+ {{ node.label }}
+ </button>
+
+ <div v-else-if="node.type === 'list'" class="space-y-2">
+ <PluginSlotNode
+ v-for="(item, index) in node.items || []"
+ :key="index"
+ :node="item"
+ @action="$emit('action', $event)"
+ @input="$emit('input', $event)"
+ />
+ <p v-if="!(node.items || []).length" class="text-sm text-gray-500 dark:text-gray-400">
+ {{ node.emptyText || "" }}
+ </p>
+ </div>
+
+ <div v-else-if="node.type === 'row'" class="flex items-center justify-between gap-3 text-sm">
+ <PluginSlotNode
+ v-for="(child, index) in node.children || []"
+ :key="index"
+ :node="child"
+ @action="$emit('action', $event)"
+ @input="$emit('input', $event)"
+ />
+ </div>
+
+ <div v-else-if="node.type === 'column'" class="space-y-4">
+ <PluginSlotNode
+ v-for="(child, index) in node.children || []"
+ :key="index"
+ :node="child"
+ @action="$emit('action', $event)"
+ @input="$emit('input', $event)"
+ />
+ </div>
+</template>
+
+<script>
+export default {
+ name: "PluginSlotNode",
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ emits: ["action", "input"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue b/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
index 22dbdda7..efe2d146 100644
--- a/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
@@ -2,20 +2,22 @@
<template>
<div class="plugin-slot space-y-4">
- <template v-for="(node, index) in nodes" :key="index">
- <component
- :is="resolveComponent(node)"
- v-bind="nodeProps(node)"
- @click="onNodeAction(node)"
- @input="onNodeInput(node, $event)"
- />
- </template>
+ <PluginSlotNode
+ v-for="(node, index) in nodes"
+ :key="index"
+ :node="node"
+ @action="$emit('action', $event)"
+ @input="$emit('input', $event)"
+ />
</div>
</template>
<script>
+import PluginSlotNode from "./PluginSlotNode.vue";
+
export default {
name: "PluginSlotRenderer",
+ components: { PluginSlotNode },
props: {
descriptor: {
type: Object,
@@ -38,67 +40,5 @@ export default {
return [this.descriptor];
},
},
- methods: {
- resolveComponent(node) {
- switch (node.type) {
- case "text":
- return "p";
- case "button":
- return "button";
- case "input":
- return "input";
- case "list":
- return "div";
- case "row":
- return "div";
- default:
- return "div";
- }
- },
- nodeProps(node) {
- if (node.type === "text") {
- return {
- class:
- node.variant === "title"
- ? "text-lg font-semibold text-gray-900 dark:text-gray-100"
- : "text-sm text-gray-700 dark:text-gray-300",
- textContent: node.value,
- };
- }
- if (node.type === "button") {
- return {
- class: "px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700",
- type: "button",
- "data-action-id": node.id,
- };
- }
- if (node.type === "input") {
- return {
- class: "w-full rounded-md border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm",
- type: "text",
- placeholder: node.placeholder || "",
- "data-input-id": node.id,
- value: node.value || "",
- };
- }
- if (node.type === "list") {
- return { class: "space-y-2" };
- }
- if (node.type === "row") {
- return { class: "flex items-center justify-between gap-3 text-sm" };
- }
- return {};
- },
- onNodeAction(node) {
- if (node.type === "button" && node.id) {
- this.$emit("action", node.id);
- }
- },
- onNodeInput(node, event) {
- if (node.type === "input" && node.id) {
- this.$emit("input", { id: node.id, value: event.target.value });
- }
- },
- },
};
</script>
diff --git a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
index a5fad059..4a3884bb 100644
--- a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
+++ b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
@@ -106,7 +106,7 @@ export default {
},
async enablePlugin(pluginId) {
await window.api.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/enable`);
- await pluginHost.loadEnabledPlugins(window.api, this.$i18n?.messages?.[this.$i18n.locale]?.plugins || {});
+ await pluginHost.loadEnabledPlugins(window.api, (key) => this.$t(key));
await this.refresh();
ToastUtils.success(this.$t("plugins.settings.enabled"));
},
diff --git a/meshchatx/src/frontend/js/plugins/PluginHost.js b/meshchatx/src/frontend/js/plugins/PluginHost.js
index 92c16166..8a2cf91b 100644
--- a/meshchatx/src/frontend/js/plugins/PluginHost.js
+++ b/meshchatx/src/frontend/js/plugins/PluginHost.js
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: 0BSD
import { validatePluginManifest } from "./pluginManifest.js";
+import { buildPluginLabelMap } from "./pluginLabels.js";
import { registerNavItem, unregisterNavItem } from "../registries/navRegistry.js";
import { registerTool, unregisterTool } from "../registries/toolsRegistry.js";
import { onWsEvent, offWsEvent } from "../registries/wsEventRegistry.js";
@@ -9,13 +10,17 @@ import { onWsEvent, offWsEvent } from "../registries/wsEventRegistry.js";
export class PluginHost {
constructor() {
- /** @type {Map<string, { worker: Worker, cleanup: Array<() => void>, manifest: PluginManifest }>} */
+ /** @type {Map<string, { worker: Worker, cleanup: Array<() => void>, manifest: PluginManifest, lastDescriptor: object | null }>} */
this.instances = new Map();
}
- async loadEnabledPlugins(apiClient, labels = {}) {
+ /**
+ * @param {(key: string) => string} [translate]
+ */
+ async loadEnabledPlugins(apiClient, translate) {
const response = await apiClient.get("/api/v1/plugins");
const plugins = response.data?.plugins || [];
+ const labels = typeof translate === "function" ? buildPluginLabelMap(translate) : {};
for (const plugin of plugins) {
if (!plugin.enabled) {
continue;
@@ -105,7 +110,19 @@ export class PluginHost {
void requestHandler(event.data);
});
- this.instances.set(pluginId, { worker, cleanup, manifest });
+ this.instances.set(pluginId, { worker, cleanup, manifest, lastDescriptor: null });
+ }
+
+ getLastDescriptor(pluginId) {
+ return this.instances.get(pluginId)?.lastDescriptor ?? null;
+ }
+
+ requestUiRefresh(pluginId) {
+ const instance = this.instances.get(pluginId);
+ if (!instance) {
+ return;
+ }
+ instance.worker.postMessage({ type: "refresh-ui" });
}
/**
@@ -135,6 +152,10 @@ export class PluginHost {
return;
}
if (message.type === "ui") {
+ const instance = this.instances.get(pluginId);
+ if (instance) {
+ instance.lastDescriptor = message.descriptor;
+ }
window.dispatchEvent(
new CustomEvent("meshchatx-plugin-ui", {
detail: { pluginId, descriptor: message.descriptor },
diff --git a/meshchatx/src/frontend/js/plugins/pluginLabels.js b/meshchatx/src/frontend/js/plugins/pluginLabels.js
new file mode 100644
index 00000000..5777c09c
--- /dev/null
+++ b/meshchatx/src/frontend/js/plugins/pluginLabels.js
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: 0BSD
+
+import en from "../../locales/en.json";
+
+/**
+ * Flatten nested locale objects into dotted keys for plugin worker translation.
+ *
+ * @param {Record<string, unknown>} messages
+ * @param {string} [prefix]
+ * @returns {Record<string, string>}
+ */
+export function flattenLocaleMessages(messages, prefix = "") {
+ /** @type {Record<string, string>} */
+ const flat = {};
+ if (!messages || typeof messages !== "object") {
+ return flat;
+ }
+ for (const [key, value] of Object.entries(messages)) {
+ if (key.startsWith("_")) {
+ continue;
+ }
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (typeof value === "string") {
+ flat[path] = value;
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
+ Object.assign(flat, flattenLocaleMessages(value, path));
+ }
+ }
+ return flat;
+}
+
+/**
+ * @param {Record<string, unknown>} messages
+ * @param {string} [prefix]
+ * @returns {string[]}
+ */
+function collectLocaleKeys(messages, prefix = "") {
+ /** @type {string[]} */
+ const keys = [];
+ if (!messages || typeof messages !== "object") {
+ return keys;
+ }
+ for (const [key, value] of Object.entries(messages)) {
+ if (key.startsWith("_")) {
+ continue;
+ }
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (typeof value === "string") {
+ keys.push(path);
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
+ keys.push(...collectLocaleKeys(value, path));
+ }
+ }
+ return keys;
+}
+
+/**
+ * @param {(key: string) => string} translate
+ * @returns {Record<string, string>}
+ */
+export function buildPluginLabelMap(translate) {
+ /** @type {Record<string, string>} */
+ const labels = {};
+ const keys = collectLocaleKeys(en.plugins || {}, "plugins");
+ for (const key of keys) {
+ const value = translate(key);
+ if (typeof value === "string" && value !== key) {
+ labels[key] = value;
+ }
+ }
+ return labels;
+}
diff --git a/meshchatx/src/frontend/js/plugins/pluginWorker.js b/meshchatx/src/frontend/js/plugins/pluginWorker.js
index ce70c13b..17f925e3 100644
--- a/meshchatx/src/frontend/js/plugins/pluginWorker.js
+++ b/meshchatx/src/frontend/js/plugins/pluginWorker.js
@@ -14,15 +14,17 @@ function handleWorkerMessage(event, post) {
const state = {
pluginId: message.pluginId,
permissions: message.permissions || {},
+ labels: message.labels || {},
ui: null,
inputValues: {},
actionHandler: null,
eventHandlers: new Map(),
+ refreshHandler: null,
};
const api = {
t(key) {
- return message.labels?.[key] || key;
+ return state.labels[key] || key;
},
async invoke(method, args = {}) {
if (method === "readPaths") {
@@ -46,6 +48,9 @@ function handleWorkerMessage(event, post) {
getInputValue(id) {
return state.inputValues[id] ?? "";
},
+ onRefresh(handler) {
+ state.refreshHandler = handler;
+ },
};
function postRequest(kind, payload) {
@@ -99,6 +104,12 @@ function handleWorkerMessage(event, post) {
state.inputValues[next.id] = next.value;
return;
}
+ if (next.type === "refresh-ui") {
+ if (typeof state.refreshHandler === "function") {
+ void state.refreshHandler();
+ }
+ return;
+ }
if (next.type === "event") {
const handler = state.eventHandlers.get(next.event);
if (typeof handler === "function") {
diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index 22d5a16c..af0ed9d2 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -139,8 +139,8 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"HTML",
"plaintext",
"micron-parser",
- "index.mu",
- "index.html",
+ "=index.mu",
+ "=index.html",
"default page",
"settings.nomad_micron_default_engine_title",
"settings.nomad_micron_default_engine_desc",
diff --git a/meshchatx/src/frontend/js/settingsSearchUtils.js b/meshchatx/src/frontend/js/settingsSearchUtils.js
index a27fbe7f..ff28c8a7 100644
--- a/meshchatx/src/frontend/js/settingsSearchUtils.js
+++ b/meshchatx/src/frontend/js/settingsSearchUtils.js
@@ -49,6 +49,9 @@ export function tokenizeSettingsQuery(normalizedTrimmed) {
function resolveSnippet(text, translateFn) {
if (!text) return "";
const s = String(text);
+ if (s.startsWith("=")) {
+ return foldForSearch(s.slice(1));
+ }
const content = s.includes(".") ? translateFn(s) : s;
return foldForSearch(content);
}
diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index ca5f9ca4..9678e8fe 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -412,7 +412,7 @@ function bootstrap() {
}
void startCodec2ScriptsBackgroundLoad();
if (GlobalState.authenticated || !GlobalState.authEnabled) {
- void pluginHost.loadEnabledPlugins(window.api).catch((error) => {
+ void pluginHost.loadEnabledPlugins(window.api, (key) => i18n.global.t(key)).catch((error) => {
console.debug("Plugin host bootstrap failed:", error);
});
}
diff --git a/tests/frontend/pluginLabels.test.js b/tests/frontend/pluginLabels.test.js
new file mode 100644
index 00000000..07899aad
--- /dev/null
+++ b/tests/frontend/pluginLabels.test.js
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it } from "vitest";
+import { buildPluginLabelMap } from "../../meshchatx/src/frontend/js/plugins/pluginLabels.js";
+
+describe("pluginLabels", () => {
+ it("builds flat plugin label map from translate function", () => {
+ const labels = buildPluginLabelMap((key) => {
+ if (key === "plugins.transport_node_monitor.title") {
+ return "Transport Node Monitor";
+ }
+ return key;
+ });
+ expect(labels["plugins.transport_node_monitor.title"]).toBe("Transport Node Monitor");
+ });
+});
diff --git a/tests/frontend/settingsSearchUtils.test.js b/tests/frontend/settingsSearchUtils.test.js
index 3735bbe4..8858f369 100644
--- a/tests/frontend/settingsSearchUtils.test.js
+++ b/tests/frontend/settingsSearchUtils.test.js
@@ -49,4 +49,9 @@ describe("settingsSearchUtils", () => {
it("matchesSettingSearch: resolves i18n keys with dots", () => {
expect(matchesSettingSearch(["app.theme"], t, "Theme")).toBe(true);
});
+
+ it("matchesSettingSearch: treats = prefix as literal text", () => {
+ expect(matchesSettingSearch(["=index.mu"], t, "index.mu")).toBe(true);
+ expect(matchesSettingSearch(["=index.html"], t, "html")).toBe(true);
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────